Release v0.4.0-alpha.1#377
Merged
Merged
Conversation
fix(live): reset lap count on Abort/Restart (scope to current run)
A manually-built heat ("build heat") could not carry a human name: heats
carried no label on the wire, so the console always *derived* the display
name from the round + the heat's position ("‹Round› Heat N" / "A-Main" …).
The RD-entered "heat id" is only the storage handle, so any intended custom
name was silently overridden by the generated convention.
Add an optional `label: Option<String>` that wins over the derived name:
- Event/Command: `Event::HeatScheduled.label` and `Command::ScheduleHeat.label`
(both additive, `#[serde(default)]`, ts-rs `optional`). Threaded through
`control_handler` so a manual build-heat persists its label; the generator
(FillRound) and open-practice paths emit `None`, keeping their auto-names.
Legacy logs (no label) read back `None`.
- Heat-list payload: `HeatSummary.label`, folded from the latest
`HeatScheduled` so `GET …/heats` serves it to the console.
- Display: `heatDisplayName` / `heatNameById` prefer a non-blank `label`,
else fall back to the existing derived convention — so a labelled build-heat
shows its name everywhere (Rounds & Heats, Live picker, heat sheet) while
generator heats keep "‹Round› Heat N" / tiers.
- Build-heat form: a "Heat name (optional)" field sends the label on
`ScheduleHeat`; blank = auto-name (None).
Tests: engine/server round-trip (label persists; legacy reads back None);
frontend label-preference + the form sending the label; the heats contract
asserts the label round-trips on the wire. `cargo xtask ci` green incl.
gen-drift (regenerated bindings/ for the new fields); frontend
build/check/lint/test/contract green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(heats): custom heat label for build-heat (overrides the auto-name)
…name The build-heat form made the RD hand-type the "Heat id" — but that's the unique internal handle, not something a human should mint. Remove the field and auto-generate a round-scoped, collision-safe id in the readable generator style (`<round>-h-<base36 suffix>`), re-rolling against the round's existing heats so two quick builds can't collide (the scheduleHeat ack dup-checks too). Submit now needs only a selected round + a non-empty lineup; the heat name stays optional. Update the build-heat unit tests (build with no typed id succeeds, id is non-empty and round-scoped, unique across two builds, label still sent) and the e2e flow (no id fill). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
ux(build-heat): auto-generate the heat id, only ask for the optional name
…+ high rate) The mock RotorHazard signal was a rigid square peak/nadir envelope — obviously generated — so the maintainer couldn't judge the marshaling evidence graph against it. Replace the generator's per-pass shape with a realistic gate-pass model: - Per-pass profile: each lap is a low baseline floor plus a Gaussian-ish bell centred on the gate crossing (rise -> peak -> fall over the approach/depart window), not a square step. The bell peaks ON the crossing tick so RH's crossing detection still fires and laps record. - Baseline noise: a few RSSI units of deterministic per-sample jitter on baseline AND peak, from a seeded xorshift64* keyed by (node, sample) — no Math.random / wall-clock, so generation stays byte-reproducible. - Lap-to-lap variation: each pass's peak height drifts a few units (seeded by node+lap), so no two passes are identical. - High sample rate: DEFAULT_TICKS_PER_LAP=48 (a lap is dozens of samples, not 6); rh-mock feed --tick now defaults to 0.015s (~67 Hz, in the 50-100 Hz band) and is documented as the wall-clock RH_UPDATE_INTERVAL. - Both the coarse streamed node_peak (col 4) and the dense peak/nadir history (cols 10/13) now carry the bell+noise, so the LIVE trace and the heat-end dense marshal trace both look like real signal. Scenarios stay meaningful under the model: noisy = marginal peak + a 3x noise floor (grainy baseline); false-pass = a small low-peak spurious secondary bump beside a real pass; missed-lap = a pass whose window is skipped; strong/weak/pack/dnf rescaled to realistic gap densities. race() seeds each node by index so co-racing nodes get distinct traces. Tests: adapt the e2e fidelity gate to assert band-membership within the model (captured == emitted within [baseline-noise, peak+noise]) instead of a single constant; the dense-history gate prints a dump-style sparkline of the captured bell trace. Add testkit unit tests for the bell shape, baseline noise, determinism, lap variation, and per-node seeding. Verified against real dockerized RotorHazard: all three rh_signal live tests pass; dense history captured 92 samples as a noisy rise->peak->fall bell (min 68 / max 157) with laps detecting. Testkit + xtask signal generator only — no Director runtime / live_state / adapter changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(testkit): realistic mock RSSI signal (gate-pass profile + noise + high rate)
…he socket, killing laps) After #250 activated the heat-end dense marshal pull in the normal flow, the RH socket flapped and no laps landed on the maintainer's live Director: the finish dance (ensure_savable_heat -> set_current_heat -> stop_race, driving RH to DONE so it pulls current_marshal_data/save_laps) re-fired in a loop, creating heat after heat / round after round, flooding+resetting the socket and stopping the live race. Root cause: the once-only guard was a `maintain`-local (`finish_deadline`) while the `finishing` trigger lived in the *shared* `armed` slot. The dense pull's burst of emits can itself drop the link; on reconnect the driver re-enters `maintain` with a fresh local guard but the SAME still-`finishing` slot, so it re-ran the whole dance — looping indefinitely. Fix: add a `done` flag to the shared `ArmedHeat` and claim the save on it, flipping `done` BEFORE any emit. A re-sent DONE, a reconnect, or a maintain re-entry are now all no-ops — the heat-end dense pull fires EXACTLY ONCE per arming (reset per fresh arm_heat). A `done`-but-no-local-settle re-entry just restarts the drain settle (no re-fire) so the slot still clears and any re-pushed dense history lands. Verified on a throwaway dockerized RH (rh_connect_live, port 5042): laps land, the connection stays Connected through the finish (no reconnect storm), and the RH log shows the save dance once (1x "Current laps saved", 1x "Heat added", dense 212 samples > 30 coarse). The fire decision is factored into a pure `claim_finish_flags` helper with a unit test asserting once-only-across-reconnect; rh_connect_live is extended to assert the save fires once + laps persist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
fix(rotorhazard): dense heat-save fires once (was looping, flapping the socket, killing laps)
Move the two forms in the Rounds & Heats stage out of inline panels and
into the app's existing modal Dialog primitive (backdrop, focus trap,
Esc-to-close), matching the ClassManager/PilotManager pattern: form body
as the Dialog children, the cancel/confirm actions in a {#snippet footer},
submit wired via onclick (the footer sits outside the <form>) while the
form's onsubmit keeps Enter-to-submit working.
- Add round / Edit round: opened by "+ Add round" and the per-round "Edit"
button (openAdd/openEdit already drive formOpen), closed on submit/cancel.
- Build heat: opened by "+ Build heat", closed on submit/cancel.
- Both Dialogs render at the component root (outside the Cards) so they no
longer leak option text into the Cards' content for scoped queries.
All field logic, validation, dynamic format params, the auto-generated
heat id, the optional label, submit handlers, toasts, and refresh-after
behavior are unchanged — this is a presentation move. The long round form
scrolls within the dialog body on short screens.
Tests: extend EventRounds unit tests with the modal flow (dialog hidden
until the trigger, opening reveals the form, cancel/submit close it) and
scope the e2e (rounds.spec.ts) to open the dialog before filling, asserting
it closes on a successful add/schedule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
ux(rounds): add-round + build-heat as modal Dialog forms
Two gaps on the RSSI evidence graph closed: (1) Hover crosshair + readout. Moving over a competitor's trace now shows a vertical guide at the cursor and a dark, high-contrast chip reading the exact race-relative time (S.mmm, the same source-clock axis as the samples + lap markers) and the RSSI sample there. The cursor's pixel-x is mapped back to a source-time by inverting the marker projection (`timeAt` = inverse of `xOf`), and the RSSI is the sample nearest that time on the trace's from/period grid. (2) Add a brand-new lap (not just edit existing ones). Clicking a competitor's trace adds a lap at the cursor's race-relative time via the existing InsertLap/insertLapCommand path; an explicit "Add lap here" button at the crosshair gives a keyboard/trackpad-accessible affordance. A per-competitor "Add lap" control (pick competitor + typed seconds) makes it work without the graph (sim heats) and with zero existing laps. All role-gated by `canControl` like every other correction; a marker click still selects (stops propagation so it doesn't also add). Tests: RssiGraph hover reports the right time/RSSI for a fixed trace; a trace click + the "Add lap here" button call onaddlap with the matching source-time; read-only hides the add affordance. MarshalingScreen: graph click → InsertLap; the explicit control adds at a typed time and works with zero laps; read-only hides it. e2e: add a lap via the control and assert it appears in the lap list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(marshaling): graph hover time/RSSI readout + add-a-new-lap
The realistic gate-pass model (Gaussian bell + baseline noise) looked real
but gave RH's two-state RSSI detector thin/fragile crossing margins: the
crest carried *bidirectional* per-sample noise, so a low ("marginal") peak
could dip below the calibrated `enter` threshold at the very tick the
crossing fires, and the thin-margin scenarios sat only ~3 RSSI units above
enter (MARGINAL_PEAK = enter+5, varied down a few percent lap-to-lap). On
real hardware that means a captured peak that never clearly exceeds enter —
no crossing registers, 0 laps — while the dense trace still captures fine.
Fix the signal model so passes stay realistic AND reliably detect:
- Shape the noise in `pass_value`: full +/- jitter on the off-gate baseline
(real texture, bounded so it stays clear of `exit` between passes), but
*rectify the crest noise upward*. `node_peak`/`pass_peak` are running
maxima on a real timer, so sensor noise around a pass crest only pushes
the reported peak up, never carves a notch below the true peak. The crest
is now the unambiguous maximum it physically is, so the captured peak
clears `enter` by the lap's full peak-to-baseline margin every time.
- Widen MARGINAL_PEAK from enter+5 to enter+15 so even the weakest scenario
clears enter with real headroom after lap-to-lap variation (still clearly
below WEAK_PEAK, so strong > weak > marginal ordering holds). This lifts
marginal/noisy/false-pass off the detection edge.
Verified end-to-end against a dockerized RotorHazard heat (seated pilot,
tick 0.015): clean 0 -> 6 laps, missed-lap skips one, false-pass adds the
spurious bump, noisy still detects. The three docker `rh_signal` tests pass
(detection asserted). New unit tests lock the invariants: the crest never
dips below the nominal peak, and marginal/noisy/weak clear enter by >=8 with
the baseline staying below exit. Deterministic (seeded RNG, no wall-clock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
…wn shows The Armed-phase "Tone in S.s" countdown (#249) never appeared because `tone_at` was always `None` on the live state, so the RD console only ever rendered the generic "Arming… stand by" fallback. Root cause: `tone_at` is derived in `heat_tone_at` from the heat's `HeatStarting` event as `recorded_at + delay_ms`. The start driver appends `HeatStarting` with `recorded_at: None`, and the single append choke point in `AppState::append` only stamped a server wall-clock `recorded_at` for `HeatStateChanged` events — so `HeatStarting` stayed untimed and `heat_tone_at` returned `None` (its documented untimed-entry path). The heat DID hold in Armed for the randomized delay and DID emit `HeatStarting` with a real `delay_ms`; only the timing anchor was missing. Fix: stamp `recorded_at` for `HeatStarting` too (it is the heat's arm instant the tone countdown anchors to), alongside the existing `HeatStateChanged` stamping. A caller-supplied timestamp (replay / test) still wins. This is timer-agnostic: the Armed hold + `HeatStarting` run in the shared `handle_transition` start driver and the fix is in the shared append path, so both Mock and RotorHazard heats now surface `tone_at` during Armed. Verified on throwaway Directors (Mock + a dockerized RH `clean` feed): the heat holds in Armed for the delay and `tone_at` counts down to zero, clearing on Running. The frontend already gated the banner correctly (`canControl ? live?.tone_at : undefined`) and has tests for both the "Tone in S.s" (controlling) and hidden (read-only) cases; they passed unchanged once `tone_at` is populated. Tests: two server regression tests prove an untimed `HeatStarting` is stamped and surfaces `tone_at` while Armed, and that `tone_at` clears on Running. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
fix(testkit): realistic mock signal reliably triggers lap detection
fix(live): Armed tone countdown shows during the start-procedure hold
The Marshaling screen sourced its competitor-name resolver's explicit `ref → pilotId` map only from `session.liveState.progress` — the GLOBAL live stream's *current* heat. A node-seeded RH heat carries ref `node-0` (not a pilot id), so the roster-id fallback misses; and for a finished / non-current heat the global stream has no progress for it, so the explicit binding misses too — the resolver fell through to the raw "node-0" label in the audit trail, ruling/protest/add-lap dropdowns, and lap headings. The durable `node-0 → pilot` bind lives in the heat's `CompetitorRegistered` facts. The heat-scope `?projection=live` fold (`snapshot_heat` → `live_state(&heat_events)`) already materializes that bind on `progress[].pilot` for ANY heat window — finished, non-current, node-seeded. So pull that per-heat fold alongside the lap list / audit / signal in `refreshMarshaling` (new `session.heatLiveState`) and feed ITS progress into the resolver's `explicitPilotByRef` (global stream merged underneath as a same-heat fallback, guarded on `current_heat === heat`). No backend change needed — the durable binding was already exposed; the screen just read the wrong source. Adds a `heatLive` test seam + a regression test: a node-seeded heat with a durable `node-0 → pilot` bind resolves the callsign in the heading, the dropdowns, and the audit line even with empty global live progress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
…ver raw ids Codifies the recurring friendly-name-leak fix as a standing rule: anywhere a value with a human name is shown, render the friendly name via the shared resolvers (competitorName/heatNameById/...), resolved from a durable source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
docs: CLAUDE.md project rules — always display friendly names
fix(marshaling): resolve pilot callsigns from the durable heat binding (not just live progress)
…get-data only
Make the RotorHazard connector treat RH purely as a start/stop/get-data
recording device: GridFPV's own start procedure (the randomized Armed hold +
the start tone) is the only delay, and RH begins recording the instant Grid
says go — no RH-side staging countdown or tone running on top of Grid's start.
Previously the adapter staged RH lazily at the go instant by emitting a reset
(stop_race + discard_laps) immediately followed by stage_race, which ran RH's
OWN staging sequence (staging tones + a 1-2s random start delay) on top of
Grid's start procedure — two competing start sequences, the root of the
staging-race-eats-laps bug the ~300ms STAGE_RESET_SETTLE band-aid papered over.
Mechanism:
* New transport `prepare_instant_start()` zeroes the CURRENT race format's
staging over the socket — `alter_race_format` with staging_fixed_tones=0,
staging_delay_tones=0, start_delay_{min,max}_ms=0, unlimited_time=1 — so
`stage_race` transitions straight to RACING with no RH staging hold/tones and
RH never auto-expires the race (Grid owns the stop). The current format id is
learned from RH's `race_status` stream (`race_format_id`, now also requested
on connect). Measured on a live RH: stage_race -> RACING drops from ~5.5s
(stock 3 tones + 1-2s delay) to ~0.9s (only RH's fixed, non-socket-settable
RACE_START_DELAY_EXTRA_SECS prestage).
* The connection lifecycle now splits across two bridge hooks: at **Staged**
the bridge prepares each RH connection (zero staging + reset to READY), well
before Grid's go; at **Running** (the Armed -> Running instant, when Grid's
tone fires) the driver emits a single `stage_race` so RH starts recording
immediately — no reset, no settle.
* Because the reset now happens at Staged (seconds before the start emit, never
the same gevent tick), the reset-vs-staging race is gone and the
STAGE_RESET_SETTLE band-aid is REMOVED.
Lap-time correctness is preserved: RH timestamps every pass relative to its own
race start and Grid derives lap times as pass-to-pass deltas, so RH's constant
prestage offset cancels — verified live (node-0 laps land near the emulated
0.6s/lap on Grid's clock).
Scope: RH connector only. The Mock/sim source and the generic runtime start
procedure are untouched. Verified on a throwaway dockerized RH: Grid runs the
Armed hold, RH starts on command with no staging hold, passes flow with correct
lap times, stop-on-command + dense data pull still work, the connection stays
stable (no reconnect storm), and the #250 once-only finish guard holds.
`cargo xtask ci` green; rh_signal + rh_connect_live docker tests pass (extended
to assert RH starts promptly on command with no staging-hold gating and that
lap times map correctly).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
feat(rotorhazard): Grid owns all start/stop timing; RH is start/stop/get-data only
…attribute (laps) GridFPV's node→pilot binding lived only in the Director's event log; the RH adapter tuned node frequencies but never told RotorHazard which pilot sits on which node. RH then raced a current heat with an empty pilots list, and its pass gate (`do_pass_record_callback`: a crossing on a node with `pilot_id == PILOT_ID_NONE` while a heat is current is dismissed "Pilot not defined") rejected every clear gate crossing → zero laps even with a pilot bound. Fix: at Stage (the #263 prepare flow), seat the heat's bound pilots onto their RH nodes before racing. The bridge resolves the heat's lineup (its durable HeatScheduled bind — node n runs lineup[n], a pilot ref) to (node_index, callsign) via the pilot directory and hands it to the connection. The driver, after the prepare reset, builds a fresh RH heat (`add_heat`), seats each bound pilot (`add_pilot` + `alter_pilot {callsign}` + `alter_heat {heat, slot_id, pilot}`), and makes it current (`set_current_heat`) — so RH records AND attributes passes, and its "Racing heat … pilots:" log names the callsigns. The seated heat doubles as the marshaling savable heat: the finish-time dense save reuses it rather than adding a separate empty heat. Robustness: seating only selects the heat as current when at least one bound pilot was actually assigned (else falls back to practice mode, which still records via the `current_heat is HEAT_ID_NONE` gate branch); the heat-slots wait requires a non-empty node→slot map; the new-pilot wait keys on an id strictly above the pre-add floor (RH re-broadcasts `pilot_data` on rename); and the seated-heat id is held outside the reconnect loop so a mid-race reconnect doesn't make the finish clobber the seated heat with an empty one. Idempotent; the #263 timing flow, the dense once-only finish, and connection stability are untouched. Adds `pilot_data` + heat `slots` capture to the adapter; extends `rh_connect_live` to assert RH's pilots list lists the bound callsign and no crossing is dismissed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
fix(rotorhazard): seat the heat's bound pilots on RH nodes so passes attribute (laps)
Record the maintainer decision (2026-06-26): drop the own-the-stack native timer-server in favor of a REQUIRED GridFPV RotorHazard plugin as the primary RH integration. - decisions.html: mark D15 superseded (text kept as history); add D16 recording the required-RH-plugin pivot, rationale, and the revision of D15's "never gate on a GridFPV timer" premise. - roadmap.html: retitle the post-1.0 native-timer-server item to the required RH plugin as the integration path; note live-dense-signal + #3 recalc payoff. - timer-adapters.html: add a direction callout in §8 — RH integration is plugin-based (required, guided install); the plugin supersedes the socket workarounds; Grid owns start/stop timing, RH = start/stop/get-data. vision.html / integrations.html contain no own-the-stack / native-timer-server language, so they need no edits (light touch, no overreach). Docs only; HTML verified well-formed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
docs: pivot timer strategy — require an RH plugin, retire own-the-stack
The required in-process RH integration that supersedes the socket-API path. Verified RHAPI surface (floor RHAPI 1.3 / RH v4.3.0+, current 1.4 / v4.4.0): socket_listen/socket_broadcast channel on RH's own socket.io under a gridfpv_* namespace (reuses the adapter's existing connection), real race.stage()/stop(), live dense RSSI via interface.seats Node history, and frequencyset_alter for calibration. #3 recalc is a re-detection over the stored dense trace, not a hardware call (detection-fidelity flagged as the load-bearing S4 risk). Five slices: S0 RH-4.4 mock-node dev harness -> S1 skeleton + gridfpv_hello handshake + guided-install UX -> S2 live dense RSSI (kills save-then-pull) -> S3 clean start/stop + per-node passes (deletes alter_race_format/seating/prestage hacks) -> S4 draggable-threshold recalc. Wired into the docs index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice S0 of the GridFPV RotorHazard plugin (D16, docs/rotorhazard-plugin.html):
the current-RH dev harness that unblocks every later slice. No plugin logic yet —
just a loadable placeholder and the harness to iterate on it in-container.
- docker/rotorhazard: build the image from RotorHazard **v4.4.0 source** (replacing
the stale cruwaller/rotorhazard:latest = 4.0.0-beta.4, below the RHAPI 1.3 / RH
4.3.0+ floor). Mock nodes + emulated mock_data CSV. Two 4.4.0 specifics:
* config.json sets GENERAL.MOCK_NODE_SIGNAL=1 — new in 4.4.0, defaults to 0 (=no
signal); 1 reads mock_data_{N}.csv (the emulated-signal path the harness needs).
* run with --data=src/server so DATA_DIR=PROGRAM_DIR (mock CSVs + user plugins/
resolve in one place; else 4.4.0 chdir's to ~/rh-data and mounts miss).
Image boots QUIET by default (no baked CSV, no auto-tune) so simulate_lap /
multi-node live tests aren't polluted; the self-demo signal (auto-tune a CSV-backed
node) is gated behind GRIDFPV_RH_DEMO=1, set only by docker-compose.
- plugins/gridfpv: placeholder plugin (empty initialize(rhapi) + manifest.json with
required_rhapi_version 1.3). Loads cleanly into the 4.4.0 container (S1 adds the
gridfpv_hello handshake).
- testkit RhContainer: point at the locally-built gridfpv-rotorhazard:4.4.0, auto-build
it if missing (keeps `cargo xtask live` one command), and mount a plugin dir
(GRIDFPV_RH_PLUGIN / start_with_plugin).
- xtask: `cargo xtask live` boots every live RH against the plugin; `rh-mock feed
--plugin` and a new `rh-mock plugin-check` (boot RH + plugin, assert it loads).
- adapter fix (4.4.0 wire change): current_laps `lap_time` went string -> number
(pretty string moved to lap_time_formatted; source/deleted now inline). RawLap typed
lap_time as Option<String>, which failed the whole snapshot deserialize -> zero laps.
Parse it permissively (serde_json::Value, advisory/unused) and skip `deleted` laps.
Adapter reads only lap_number + lap_time_stamp (both stable). Old golden fixture
still parses.
Gates: cargo xtask ci green; cargo xtask rh-mock plugin-check PASS; all RH-relevant
live targets green on 4.4.0 (rh_live, rh_signal, rh_connect, engine e2e). Pre-existing,
unrelated: gridfpv-server's full_event_live live test doesn't compile (issue #72
router(AppState)->router(EventRegistry) drift; local-only, not in CI) — untouched here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The local-only `gridfpv-server` full_event_live test still drove the pre-#72 single-AppState router: `router(state)`, bare `/control` / `/stream` / `/snapshot/...` routes, and an ad-hoc `AppState::new(InMemoryLog)`. Since #72 made the protocol surface event-rooted (`router(EventRegistry)`, routes under `/events/{event}/...`), this test no longer compiled — and because the live suite isn't in CI, the rot went unnoticed. Migrate it to mirror the already-updated `tests/control.rs`: build a fresh `EventRegistry`, drive the built-in Practice event, root every route + scope under it. No behavioral change to the assertions (heat loop → live passes → protocol client → marshaling void → auth gate). Verified: compiles; `cargo test -p gridfpv-server --features live --test full_event_live -- --ignored` passes; `cargo xtask ci` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runtime hardening: timed auto-end fallback, per-heat clocks, no-replay startup, atomic writes
Raced-round freeze, run-scoped protests, param + penalty validation (D23/D24)
Console + protocol hardening, durable names, server-anchored staging + the audit docs
…ctions; RSSI zoom The void/re-detection split-brain (live repro: Audit Shakedown, Quali, Blaze): the RD removes a valid-crossing-but-not-a-lap detection, and the threshold tuner — which reads only the RSSI trace — keeps proposing the crossing back as 'a lap to add'. The removal record and the tuner now share the same data: - The LapList projection carries each competitor's RD-VOIDED passes (CompetitorLaps::voided — at + the voided pass's own offset), folded from the same corrected-passes fold that drops them from the laps (void-the-void un-records them). The console renders them struck-through in place. - Re-detection SUPPRESSES any detected crossing within match tolerance of a voided instant: never counted as an add, never inserted by a commit, shown as 'voided by you, stays removed' in the preview and the summary. The marshaling surface consolidates around the lap-times box (user-specced): - Tune detection is purely the enter/exit levels + commit; the LAP BOX becomes the live detection readout while actively tuning (kept/added/removed/voided rows) — and only on explicit intent (a drag or typed level), so a trace whose recorded thresholds disagree with the official laps can't hijack the list. - Each lap row carries Remove directly; selecting a row opens an INLINE editor (Split / Edit time / Insert after / Throw out) where the lap is. The separate correction and Add-a-lap panels are gone; adding is an inline control in each competitor's box (and still available when a pilot has no laps at all). - The RSSI graph zooms: wheel at the cursor, +/−/Fit buttons, drag-to-pan while zoomed; the sample downsampling budget follows the visible window so zooming reveals real detail; markers/windows clip to the plot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ag-aware final lock (D25) The round-2 deep dive over the shared-removal-record work — two adversarial reviews plus a seeded marshaling combo fuzzer (docs/release-audit-2026-07.html §4): - Re-detection suppression runs AFTER diff matching: applied before, a voided instant could steal a surviving pass's nearest crossing and a commit would void the lap the RD kept; a re-added lap fought the stale record forever. - The removal record carries the RAW instant (the trace knows no re-times) and the standing void's offset; the projection's void-the-void resolution walks the chain with parity (depth-3 remove/restore/re-remove works); splits resolve their source recursively (splitting a split-synthetic used to ack ok and do nothing). - RESTORE is first-class: require_void_target admits a DetectionVoided target (void-the-void was unreachable through the command layer — a misclicked Remove was permanent) and the struck removal rows carry a Restore button. - heat_of_offset resolves bridge-stamped passes BY TAG, agreeing with the window fold — the positional resolution let rulings on late tagged passes bypass (or spuriously hit) the official-result lock. - Run-scoped + effectively-once command layer: abandoned-run targets rejected (stale screens told, not silently no-oped); protests and heat-voids require a current run (a pre-run filing was an invisible Finalize wedge); one standing throw-out per lap and DQ per pilot/heat (stacked duplicates made reversal a silent no-op); voiding a thrown-out lap's pass rejected (dangling ruling); insert/adjust/split instants validated — positive, bounded 24h, and never colliding into a fuzz-caught ZERO-DURATION lap. - Run-aware driver rechecks: each runtime clock captures a spawn watermark (the heat's latest transition offset) and stands down if ANYTHING moved — same-state rechecks couldn't tell a re-run from the run they were spawned for. - Console: tuning intent keyed per heat+pilot (a reused node seat carried stale levels + intent across heats); shared time inputs reset on selection/pilot moves; the preview renders once for a dual-adapter pilot; removal rows interleave chronologically; graph pan defers pointer capture until a real drag so marker clicks work while zoomed. - Fuzz: seeded 230-300-op command storms incl. hostile targets + a mid-race phase — no 5xx, no wedge, every projection invariant held, zero laps lost on the live heat under marshaling fire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshaling: shared removal record, run-scoped rulings, lap-list corrections + RSSI zoom (D25)
…trip, orphan pruning + zoom tests The release-polish batch over the deferred audit items: - FINAL FREEZE (audit round-2 §4 deferral, the top remaining integrity item): a pass landing at/after a heat's Finalized transition never joins its window (heat, class, and live folds) — a delayed RotorHazard catch-up pass, tagged or untagged, could silently change an official result with no command and no audit entry. Rulings are unaffected: Revert re-opens marshaling, not the observation record. - Version-skew rendering: an unmodeled timer kind now tags/labels/tones as unknown with an update nudge (it mislabeled as RotorHazard and field access CRASHED the Timers page); an unmodeled result metric labels 'unsupported' instead of a DNF-reading em dash. - Start-procedure round-trip: the rounds form models only the min/max delay but the server replaces the round wholesale — editing any round ERASED a stored start tone (and would flatten a future non-randomized mode). The stored procedure now round-trips verbatim under the form's fields, the same preserved-seeding pattern that saved bracket chains. - Housekeeping: bridge + presence tasks EXIT when their event is deleted (the log Arc kept orphans polling forever) and the spawners prune their attached sets; the presence reconciler dedups CompetitorSeen within a poll batch. - Test debt: the RSSI zoom/pan surface gets its suite — wheel/buttons/Fit, and the deferred-capture regression (a plain marker click selects while zoomed; a real drag captures and pans). Pan/zoom coordinates are NaN-guarded against coordinate-less synthetic pointer events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release polish: the Final freeze, skew-safe rendering, tone round-trip, orphan pruning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Timers are dumb emitters; GridFPV owns lap semantics. The trigger: a start-line double-detection gave every pilot a phantom 0.004s 'lap 1' — and RotorHazard HAD MinLapSec=10 set, but its default behavior records-and-highlights rather than discards, so the sub-min lap reached us with no invalid marker. Enforcement now lives in the system of record: - RoundDef.min_lap_secs (optional; the form seeds 5s for new rounds; 0/absent = off so pre-existing rounds keep bit-identical results; >600s rejected as a typo; FROZEN once the round has raced — it re-scores official heats, D24). - The corrected-passes fold auto-suppresses a RAW pass that would close an under-floor lap (keep-first per burst) — applied identically in the lap list, the live view, and every scoring path via corrected_passes_with_floor / lap_list_marshaled_with_floor / live_state_over_with_floor, so the score and the lap list can never disagree about an echo. The log keeps every raw pass: suppression is a view-fold rule, never data destruction. - The removal record grows a reason (VoidReason::Marshal | UnderMinLap): auto- removed crossings render 'under min lap, auto-removed' beside marshal voids, and Restore branches — a marshal void restores via void-the-void; a floor suppression restores via an AdjustLap re-asserting the raw instant (an explicit ruling always outranks the floor, so inserted/split/re-timed passes are exempt by construction; a whoop track's genuine 2s laps stay scoreable). - The pilot ws view (whole-log, cross-round) stays unfloored by design — rounds carry different floors; the per-heat views are the authoritative surfaces. - Rides along: the gridfpv_mock plugin lands node RSSI back at BASELINE after each injected pass — parked-at-peak nodes read as 'sitting on the gate' at the NEXT race start, which is what fired the phantom double in the first place. Docs: decisions.html D26 + marshaling.html note. Tests: 5 fold suppression cases, the scoring seam (a 4ms echo can no longer be a best lap), freeze + normalization + bounds, console removal-row label + Restore command, rounds-form round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GridFPV-native minimum lap time (D26): the floor lives in the system of record
…om the console
RD feedback from the field: the countdown is seconds long and nobody races it
with a Skip button ('I usually just hit start anyway'), and Force end read as
an alarming override when it is simply how a practice/timed heat ends early.
- Running exposes Stop (the unchanged ForceEnd wire command) + the off-ramps;
Armed exposes only Abort/Restart while the countdown runs itself.
- The whole 'override' concept goes with it: tag, styling, isOverride() —
actions are just actions now, styled by primary/destructive as before.
- SkipCountdown stays a valid wire command (scripts/tests); the console simply
no longer offers it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Console: one plain Stop button; SkipCountdown + the override tag retired
…tart fixes Field reports, same session: 1. Callouts only played on the Live Race Control page — the whole audio stack (player, speech queue, start/end tones, lap detection) lived in that screen and died on navigation. It now lives in an app-shell controller (raceDayAudio.svelte.ts) mounted once from App.svelte, consuming the same app-wide live stream the global header uses: a running race is audible from marshaling, rounds, results — anywhere. The Live page keeps the Callouts toggle + control-click unlocks, driving the shared instance. Bonus: page navigation no longer remounts the detectors, so returning to Live mid-race can never re-buzz the start tone. 2. ~13s of race with no tones or callouts. MEASURED the pipeline first: a scratch heat instrumented at 200ms poll showed passes visible in the live projection 0.22-0.50s (median 0.33s) after the crossing — the data path is sub-half-second. The stall is the browser TTS engine: its first-ever utterance can take seconds or die SILENTLY while the backend spins up, and the serialized queue dammed every later callout behind the corpse. Two fixes: warmUp() speaks a volume-0 utterance on the RD's first gesture (the same one-time unlock that resumes the AudioContext), and a per-utterance dead-engine WATCHDOG (6s) that cancels + advances so one swallowed utterance can never silence the rest of the race. Noted for a later slice: adapter-appended passes carry no recorded_at (the latency measurement had to poll live instead of reading the log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Race-day audio on every page + TTS cold-start warm-up/watchdog
…etry, stale-replay gate
The field report ('first lap reads 7s but arrived 25s into the race') unwound
into three RH-adapter defects, diagnosed by probing the console's actual
websocket path against a live rig:
- prepare_instant_start zeroed the staging delays of the format that was
current AT STAGE TIME — but seating the heat switches RH's effective format
to the heat's CLASS format (RHRace's class_format_id override), so the race
staged with STOCK multi-second staging tones + delays on top of Grid's own
start procedure. Every race began ~5-7s after Grid's go (a DB-bloated RH
stretched it past 15s); lap stamps stayed race-relative so results were
self-consistent and the skew was invisible everywhere except live: laps,
tones, and callouts all arrived that late. The prepare now re-runs right
before stage_race, when the effective format is current and RH is READY.
Measured on the rig: the websocket-path lap latency fell from a constant
~7.1s to 0.28-0.37s (median 0.32s).
- A busy RH ('Attempted to stage race while status is not ready') silently
DROPS a stage — the race never starts and no passes record. The driver now
re-emits stage_race every 5s inside the settle window until RH confirms
RACING (paced above RH's stock staging so a retry can never restart a
countdown in progress).
- Lap records drained BEFORE this arming's RACING confirmation are a stale
replay — a busy RH re-broadcasts the PREVIOUS race's current_laps snapshot,
and remapping it contaminated the fresh heat with the last race's laps
(observed live: a new heat opening on lap 6+). ArmedHeat now carries a
started flag (set on SessionStarted, shared across reconnects so mid-race
snapshot replays still dedup) and pre-start passes are dropped.
Operational note: a long-lived test rig accumulates RH heats (one per race,
'Heat 166' by tonight) and slows staging/saves — reset the container's DB
periodically; the retry + gate make the failure mode safe rather than corrupt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RH adapter: races start on Grid's go (effective-format zeroing) + stage retry + stale-replay gate
…, midpoint Split RD field feedback, third round tonight: - The time input opens PRE-FILLED with the selected lap's current time and 'Save time' arms only once the value changes — the RD reads, tweaks, saves. The new duration re-times the lap's closing pass (start + duration), so the correction is expressed in the unit RDs actually think in, never a free-typed absolute instant (which is what the input used to mean). - Split needs NO time: it halves the lap at its midpoint — the case is a missed gate crossing folding two real laps into one double-length lap, and the RD tunes either half with Save afterwards if the halves weren't even. - 'Insert after' is gone; the per-competitor '+ Add lap' footer covers insertion. - Remove vs Throw out spelled out in the tooltips: Remove = this crossing never really happened (noise/reflection; the pass is deleted and neighbouring laps merge); Throw out = the lap happened but doesn't count (course cut/penalty; stays on the clock, excluded from scoring). Remove edits reality; Throw out edits the score. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshaling lap editor: prefilled lap time + Save, midpoint Split, Insert-after retired
…heat rulings Design call with the RD: protests/rulings STAY on the marshaling page (rulings belong next to the evidence — a DQ or a protest resolution is made looking at the laps, the trace, and the audit, and a separate page would make every adjudication a context switch on the same heat). Revisit a dedicated cross-heat protest queue if/when pilot-filed protests arrive. What changes is the SEAM: the per-pilot surfaces (trace, tune, laps & corrections) and the heat-scoped surfaces (penalties, protests, result lifecycle, void) now sit under two labeled region heads with a deliberately heavy divider between them — sunlit-laptop legible, and the heat region reads as a distinct zone rather than more pilot UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshaling: two labeled regions — pilot marshaling vs heat rulings & protests
…he settings gear retired RD-requested v1 simplification: - The header's '← Switch event' button is gone: the breadcrumb's Home/Events crumbs are the way back, and the context header stays purely informational. - The topbar 'Setup wizard' relaunch button is gone: the wizard still runs automatically once when an event is created, and everything it configures stays editable on the regular pages — a manual re-run had no use. - The settings gear (and its dialog) is gone: its only content was the manual bearer-token field, which v1 doesn't use — on the loopback console no token is needed at all, and when a privileged action ever does need one, the lazy TokenDialog prompt (unchanged) asks for it automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v1 chrome trim: Switch event, wizard relaunch, settings gear retired
…lds, macOS leg
The alpha release scheme (RD-approved):
- ONE version everywhere: [workspace.package].version = 0.4.0-alpha.1, inherited
by every spine crate; the standalone src-tauri crate, tauri.conf.json, and the
console package.json are kept in lock-step by the new 'cargo xtask version'
command (prints current with no args; refuses malformed or partial bumps).
Scheme: 0.x milestones, -alpha.N/-beta.N pre-releases; CONTRACT_VERSION stays
the independent wire-compat integer. (Also: the workspace repository URL still
pointed at GitLab — now GitHub.)
- GET /about serves { name, version, contract_version } and the console renders
a fixed build-stamp footer from it (field-support: a bug report quotes the
version of the rig it came from). Contract-pinned.
- The DESKTOP builds carry the 'live' feature: the product's whole job is
talking to real timers, and the alpha field test runs against real RH
hardware. The TLS stack is vendored, so the portable stays one file.
- macOS (Apple Silicon) joins the release matrix beside Linux and Windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v0.4.0-alpha.1: one product version, live desktop builds, macOS release leg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The first alpha release: the full primitives-v1 line — race running end-to-end (lifecycle, timed/laps formats, brackets), marshaling with signal-as-evidence + the shared removal record (D25), native min-lap enforcement (D26), app-wide race audio, the RH start-sync fix, and the v1 console. One product version everywhere (
cargo xtask version),/about+ build-stamp, and native desktop portables (Linux/Windows/macOS, all with the live RH adapter).Tag after merge:
v0.4.0-alpha.1.🤖 Generated with Claude Code